Building Expense Tracker iOS App with Core Data & SwiftUI
Published at Apr 20, 2020
Persisting user data in a productivity-based offline application is the essential primary feature that we need to provide to users. There are many different data types associated with the recommended native storage solution in iOS, such as:
BLOB
(Binary Large Object) type. Music, image, video, JSON, Plist archive files fall under this type. Apple recommends storing this in a file under the User document or temporary cache directory folder.UserDefaults
Key-Value storage type. It is a dictionary-based type suitable for storing bite-sized user preferences.Core Data
object graph-based storage. It's the best for storing relational based object similar to a database.
According to Apple, Core Data
is recommended to use when we want to store data permanently for offline use or even as a cache when there is no internet connection. Under the hood, Core Data
uses high performance and compact SQLite
database as its default persistent store. There are many advantages of using Core Data
instead of accessing SQLite
API manually such as:
- Automatically handles the mapping of
object graph
to the database storage. - Efficient memory usage with the use of
fault
andbatch
to load the object and property as needed. Undo
andRedo
ofindividual
orbatched
changes.- Provides
background
data task API to handlelarge dataset
imports without blocking mainUI thread
. - View synchronization between data source and UI. For
UIKit
,FetchedResultsController
can be used to sync data changes in table and collection views. While inSwiftUI
, we can use@FetchedResults
property wrapper to update the state of the View whenever the data source changes. - Provides
versioning
andmigration
whenschema
changes.
At last, our app size won't be affected as Core Data
is built into the platform itself. Apple also uses it in most of its productivity-based apps, so we can ensure it is a reliable and battle-tested framework.
What We Will Build
In this tutorial, we are going to build an expense tracker
iOS app using Core Data
and SwiftUI
. Here are the main features of the app:
Create, edit, and delete
expense logs.- Display
list of expenses
. Filter
expenses by categories and search query.Sort
expenses bydate or amount
in the order ofdescending or ascending
.- Display the
dashboard
of total expense sum for all and each category. - Display distribution of each category using a
pie chart
.
Before you begin, you can download and play around with the app from the completed project GitHub
The Starter Project
To begin, you need to download the Starter Project from GitHub. Most of the UI components, standard models, and helpers are already included in the starter project, so we can focus on implementing Core Data
Stack within the app.
Here are the main models provided inside the starter project:
Category
enum. It is an abstraction for the expense category, such asentertainment, food, transportation, utilities, etc
. Each expense log belongs to a category.SortOrder and Sort type enums
. Both are an abstraction for the representation of sorting bydate/order
inascending/descending
order.Utils
struct provides a static property to retrieveformatter
for number and date inside the app.
The app has two primary tabs, dashboard
and logs
tabs. For Views, we have several main components, such as:
DashboadTabView
. It showcases the sum of the total expenses for all categories, individual categories, and categories expenses sum distribution inside apie chart
.LogsTabView
. This tab is a container that consists of the filters selection, sort by and order selection, and list of logs.LogFormView
. This view is a form used for creating and editing log. It has text fields for name and amount as well as a picker for date and category selection.FilterCategoriesView
. It is a carousel scroll view where users can apply multiple selections of categories as a filter. It accepts the binding ofSet<Category>
to bind the data from the parent state.SelectSortOrderView
. It is aHStack
where the user can pick sort-by and order by type using segmented controls. It accepts the binding ofSortOrder
andSortType
to bind the data from the parent state.PieChartView
. It is a Pie Chart View by AppPear. I cloned and made a slight modification to the source code so it can display different colors for each category.
Try to build the app and play around with it. The dashboard tab will be empty as we don't have any data yet to display. While in the logs tab, you can play around by applying categories filter and sort order types. Try to tap on the add button; the form will be presented, but the save button won't create a new log for now as we haven't implemented the Core Data persistence into the app.
Create Managed Object Data Model
To begin, let's create a new managed object data model file. Click on Menu Bar > New > File
. Select Data Model
from Core Data
section and click next. You can also type data model
from the filter text field
to find it. Give it ExpenseTracker.xcdatamodeld
as the filename.
Click on the new file. It will open the Core Data editor
. Click on the Add Entity
button at the bottom to create one entity. Rename the entity to Expenselog
. Click on the ExpenseLog
, from the editor attributes
section, click on +
button 5 times to create new attributes. Here are the attributes with name and type association:
amount
:Decimal
.date
:Date
.category
:String
.id
:UUID
.name
:String
.
Rename all the attributes to match the list above. Xcode will automatically generate the managed object model subclass for the ExpenseLog
. Close and reopen the Xcode to make sure the autogenerated class is added correctly to the project by Xcode.
Create Core Data Stack and Inject Managed Object Context Dependency
Before we can use Core Data
, we need to initialize a NSPersistentContainer
at first. We will encapsulate this into the CoreDataStack
class. In the initializer, we pass the containerName
for initializing NSPersistentContainer
, the container name itself is the name of the xcdatamodeld
file, which in our case is ExpenseTracker
.
Using NSPersistentContainer
that was introduced by Apple in iOS 10, we don't have to manually initialize all Core Data
Stacks such as the persistent store coordinator, description, file URL, main managed object context
. It will automatically handle it for us as well as exposing property for the UI context and a function to generate a background thread context
.
The ManagedObjectContext
class itself will be the main workhorse where all the managed object models will be stored. It is not a thread-safe object, so we have to be very careful only to use the main thread context when fetching data from UI. A background thread context is suitable when we want to import large datasets from an API on the internet.
Create a new Swift File named CoreDataStack
and copy the following code below. I also added a simple extension to save context only if it has changed.
import CoreData
class CoreDataStack {
private let containerName: String
var viewContext: NSManagedObjectContext { persistentContainer.viewContext }
private lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: containerName)
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
print(error.localizedDescription)
}
print(storeDescription)
})
return container
}()
init(containerName: String) {
self.containerName = containerName
_ = persistentContainer
}
}
extension NSManagedObjectContext {
func saveContext() throws {
guard hasChanges else { return }
try save()
}
}
Next, we will inject the managed object context into the root view using SwiftUI Environment
so all the children view can explicitly receive this using environment keypath
. We will do this in the SceneDelegate
, copy the following code into the file.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var coreDataStack = CoreDataStack(containerName: "ExpenseTracker")
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
.environment(\.managedObjectContext, coreDataStack.viewContext)
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
}
Extend Generated ExpenseLog Managed Object Model
Before we begin integrating the Core Data
model to our views, we will create an extension for the ExpenseLog
model that will make our life easier later. In here, we declare several properties such as:
categoryEnum
. Core Data doesn't support enum as the attribute for an entity. We store thecategory
asString
, so using this computed property, we can easily access thecategory
enum type for ourExpenseLog
.nameText
. Core Data attributes are optional, so to make it easier integrating with SwiftUI, we create a non-optional computed property to return the nameString
with an empty string as default value.dateText
. In ourLogListView
, we use a relative date formatted text to display the date of the expense relative to the current date.amountText
. This helps us to display theamount
as a currency number formatted text.
Create a new Swift file named ExpenseLog+Extension
inside the Models
folder and copy the following code.
import Foundation
import CoreData
extension ExpenseLog: Identifiable {
var categoryEnum: Category {
Category(rawValue: category ?? "") ?? .other
}
var nameText: String {
name ?? ""
}
var dateText: String {
Utils.dateFormatter.localizedString(for: date ?? Date(), relativeTo: Date())
}
var amountText: String {
Utils.numberFormatter.string(from: NSNumber(value: amount?.doubleValue ?? 0)) ?? ""
}
Before we move to the next section, navigate to Sort.swift
file, and implement the sortDescriptor
computed property to return the NSSortDescriptor
based on the sort type of date or amount. We will use this on the next section to create a fetch request in the LogListView
struct ExpenseLogSort {
// ....
var sortDescriptor: NSSortDescriptor {
switch sortType {
case .date:
return NSSortDescriptor(keyPath: \ExpenseLog.date, ascending: isAscending)
case .amount:
return NSSortDescriptor(keyPath: \ExpenseLog.amount, ascending: isAscending)
}
}
}
Using Fetch Request with Predicate and Sort Descriptor to Query Logs in LogListView
In this section, we are going to add FetchRequest
on the LogListView
to query our expense logs dataset from CoreData
. To query data from a managed object context, we need to use NSFetchRequest
. It will have several parameters to configure:
- Entity Name. The string text of the entity named in the Data Model.
- Sort Descriptors. An array of
NSSortDescriptor
to configure the order of the result set from Core Data. It uses the property key and sorts it in ascending or descending order. - Predicate (Optional).
NSPredicate
object where we can filter the results set by constraining the result to match a specific expression and value (similar toWHERE
statement in SQL)
In SwiftUI
, Apple creates a property wrapper named @FetchRequest
where it drives and binds the View using a NSFetchRequest
. Whenever the result set changes, it will update the View depending on the state of the data source. It makes sure the View and data source are synchronized automatically.
Navigate to the LogListView
file, then copy the following code below:
import SwiftUI
import CoreData
struct LogListView: View {
// 1
@Environment(\.managedObjectContext)
var context: NSManagedObjectContext
// 2
@FetchRequest(
entity: ExpenseLog.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \ExpenseLog.date, ascending: false)
]
)
private var result: FetchedResults<ExpenseLog>
// 3
init(predicate: NSPredicate?, sortDescriptor: NSSortDescriptor) {
let fetchRequest = NSFetchRequest<ExpenseLog>(entityName: ExpenseLog.entity().name ?? "ExpenseLog")
fetchRequest.sortDescriptors = [sortDescriptor]
if let predicate = predicate {
fetchRequest.predicate = predicate
}
_result = FetchRequest(fetchRequest: fetchRequest)
}
// 4
var body: some View {
List {
ForEach(result) { (log: ExpenseLog) in
Button(action: {
// TODO: Implement Edit
}) {
HStack(spacing: 16) {
CategoryImageView(category: log.categoryEnum)
VStack(alignment: .leading, spacing: 8) {
Text(log.nameText).font(.headline)
Text(log.dateText).font(.subheadline)
}
Spacer()
Text(log.amountText).font(.headline)
}
.padding(.vertical, 4)
}
}
.onDelete(perform: onDelete)
}
}
private func onDelete(with indexSet: IndexSet) {
// TODO: Implement Delete
}
}
To help you understand the detail of the code, I provided a detailed explanation for each of the number points:
- Using SwiftUI
@Environment
property wrapper, we inject the managed object context-dependency from the parent view. - Using
@FetchRequest
property wrapper, we provide a default initializer and store theFetchedResults
in a property namedresult
. The default fetch request won't have any predicate and sort the result by date in descending order. - We create an initializer that accepts an optional predicate and sort descriptor. The predicate will be used to filter the results based on selected categories and search text filter from the
LogListView
while sort descriptor will be used to sort the result based on amount or date in ascending or descending order. - Inside the
List
, we useForEach
passing theresult
ofExpenseLog
that is already conforming toIdentifiable
protocol. In each loop, we wrap the row inside aButton
so it can be tappable to invoke a function. The row itself usesHStack
containing the icon, text for name, formatted date, and amount.
Next, let's move to ExpenseLog+Extension
file to add a method to generate NSPredicate
based on the selected categories and search text. Copy the following code into the body of extension.
extension ExpenseLog {
// ...
// 1
static func predicate(with categories: [Category], searchText: String) -> NSPredicate? {
var predicates = [NSPredicate]()
// 2
if !categories.isEmpty {
let categoriesString = categories.map { $0.rawValue }
predicates.append(NSPredicate(format: "category IN %@", categoriesString))
}
// 3
if !searchText.isEmpty {
predicates.append(NSPredicate(format: "name CONTAINS[cd] %@", searchText.lowercased()))
}
// 4
if predicates.isEmpty {
return nil
} else {
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
}
}
Here is the detailed explanation for each of the number points above:
- We declare a static function that accepts an array of categories and search text
String
. In the beginning, we initialize an empty array ofNSPredicate
. - If the categories array is not empty, we map it to an array of string and append an
NSPredicate
using theIN
expression to only return the category is contained inside the array. - If search text is not empty, we append the
NSPredicate
usingCONTAINS[cd]
. This will only return the result if thename
attribute contains thesearchText
String
usingcase insensitive comparison
. - If predicates are not empty, we use
NSCompoundPredicate
to combine the predicates in the array using similarAND
expression in SQL. If it is empty, we returnnil
, which won't filter the results in the fetch request.
Finally, let's move to the LogsTabView
file and add the following code where we initialize the LogListView
.
LogListView(predicate: ExpenseLog.predicate(with: Array(selectedCategories), searchText: searchText), sortDescriptor: ExpenseLogSort(sortType: sortType, sortOrder: sortOrder).sortDescriptor)
Here, we initialize the LogListView
using the predicate from the helper method we create before. The LogListView
itself already provided the states for the set of selected filters, selected sort type and sort order, as well as the search text string. ExpenseLogSort
sort is a wrapper that accepts the sort type and sort order and generates a sort descriptor based on those two values.
Try to build and run the project to make sure it compiled successfully. As of now, we don't have any data yet to display. So, let's move on to the next section, where we will implement create expense log!
Create or Edit Expense Log from LogFormView
In this section, we'll be working on the create expense log feature using Core Data. The included LogFormView
already provide a form where user can input the expense data such as name, amount, category, and date. We just need to handle the save function by creating an expense log managed object and save it into the managed object context.
Navigate to LogFormView
file and add the following code.
import SwiftUI
import CoreData
struct LogFormView: View {
// ....
//1
var logToEdit: ExpenseLog?
var context: NSManagedObjectContext
// 2
var title: String {
logToEdit == nil ? "Create Expense Log" : "Edit Expense Log"
}
// ...
// 3
private func onSaveTapped() {
let log: ExpenseLog
if let logToEdit = self.logToEdit {
log = logToEdit
} else {
log = ExpenseLog(context: self.context)
log.id = UUID()
}
log.name = self.name
log.category = self.category.rawValue
log.amount = NSDecimalNumber(value: self.amount)
log.date = self.date
do {
try context.save()
} catch let error as NSError {
print(error.localizedDescription)
}
self.presentationMode.wrappedValue.dismiss()
}
}
Here is the detailed explanation for each of the number points above:
- We add two new properties. First, the managed object context (notice we don't use environment to inject this as this View will be presented so it won't be the child of the root view). Second, we add an optional
logToEdit
property that will be passed when we want to edit a log. - We update the
title
computed property to dynamically return the title for the navigation bar, depending on whether thelogToEdit
exists or not. - In the
onSaveTapped
method, there are two possibilities of the log object depending on whether thelogToEdit
exists. If it exists, we assign that as the log, otherwise we initialize a newExpenseLog
in the context as well as assigning uniqueUUID
. Next, we assign all the attributes using the value from the properties of the state. Finally, we just need to save the context and dismiss the presented form view.
Next, let's move to the LogsTabView
. In this View, we have a navigation button item where the user can tap to create a new log. In SwiftUI, to be able to present a model view, we can use the sheet
modifier. The sheet uses the state to determine whether it should be displayed. In this case, we use isAddFromPresented
boolean property to toggle this to true
when the user taps on the add button. Inside the sheet, we just need to declare the LogFormView
passing the managed object context. Don't forget we also need to inject the managed object context property using the @Environment
property wrapper.
struct LogsTabView: View {
@Environment(\.managedObjectContext)
var context: NSManagedObjectContext
// ....
var body: some View {
NavigationView {
...
.sheet(isPresented: $isAddFormPresented) {
LogFormView(context: self.context)
}
...
}
}
}
Finally, let's move on to the LogListView
. In this View, the user can tap on the row of the expense log to edit the associated log. In this case, we already wrapped our row inside a Button. Inside the action closure, we just need to assign the logToEdit
state property with the selected log. The logToEdit
state property will control whether the sheet containing the LogFormView
will be presented using the binding passed to the initializer. If logToEdit
is not nil, the sheet will be presented. In this case, we declare the LogFormView
passing the logToEdit
as well as initializing all the form properties using the existing log attributes values.
struct LogListView: View {
// ...
@State var logToEdit: ExpenseLog?
var body: some View {
List {
ForEach(result) { (log: ExpenseLog) in
Button(action: {
self.logToEdit = log
}) {... }
...
.onDelete(perform: onDelete)
.sheet(item: $logToEdit, onDismiss: {
self.logToEdit = nil
}) { (log: ExpenseLog) in
LogFormView(
logToEdit: log,
context: self.context,
name: log.name ?? "",
amount: log.amount?.doubleValue ?? 0,
category: Category(rawValue: log.category ?? "") ?? .food,
date: log.date ?? Date()
)
}
}
}
// ...
}
That's it! Try to build and run the project. Create several logs to play with the filtering and sorting! If you have a problem setting the amount text field, make sure to press enter/return on the keyboard before assigning the value so it can be properly formatted to number.
Delete Expense Log from LogListView
For the deletion of log, we just need to implement the onDelete(with:IndexSet
. This method will be invoked when the user performs a left swipe gesture to delete a row in the list. It will pass the IndexSet
containing the index of rows. In this case, we can loop, retrieve the log, and pass it to the context for deletion. Also, we need to save the context for the deletion to be committed to the data store.
private func onDelete(with indexSet: IndexSet) {
indexSet.forEach { index in
let log = result[index]
context.delete(log)
}
try? context.saveContext()
}
Using Expression and Fetch Request to query total expenses sum grouped by category in DashboardTabView
In this section, we are going to focus on the DashboardTabView
. We need to fetch the sum of the total expenses for grouped by each of the categories so we can display it in a List
as well as the Pie Chart.
Navigate to the ExpenseLog+Extension.swift
file and copy the following code into the body of extension.
extension ExpenseLog {
// ....
// 1
static func fetchAllCategoriesTotalAmountSum(context: NSManagedObjectContext, completion: @escaping ([(sum: Double, category: Category)]) -> ()) {
// 2
let keypathAmount = NSExpression(forKeyPath: \ExpenseLog.amount)
let expression = NSExpression(forFunction: "sum:", arguments: [keypathAmount])
let sumDesc = NSExpressionDescription()
sumDesc.expression = expression
sumDesc.name = "sum"
sumDesc.expressionResultType = .decimalAttributeType
// 3
let request = NSFetchRequest<NSFetchRequestResult>(entityName: ExpenseLog.entity().name ?? "ExpenseLog")
request.returnsObjectsAsFaults = false
request.propertiesToGroupBy = ["category"]
request.propertiesToFetch = [sumDesc, "category"]
request.resultType = .dictionaryResultType
// 4
context.perform {
do {
let results = try request.execute()
let data = results.map { (result) -> (Double, Category)? in
guard
let resultDict = result as? [String: Any],
let amount = resultDict["sum"] as? Double, amount > 0,
let categoryKey = resultDict["category"] as? String,
let category = Category(rawValue: categoryKey) else {
return nil
}
return (amount, category)
}.compactMap { $0 }
completion(data)
} catch let error as NSError {
print((error.localizedDescription))
completion([])
}
}
}
}
Here is the detailed explanation for each of the number points above:
- We declare a static method that accepts a managed object context. As this is an asynchronous operation, we'll use a closure as the completion handler passing the tuple array of
Category
associated with the value of the total sum for it. - We declare
NSExpression
passing the keyPath ofamount
to the initializer. We create a secondNSExpression
passing:sum
for the function to the initializer as well as passing the previous expression to the arguments. Finally, we create aNSExpressionDescription
and assigns the expression and name. The expression result type is a.decimalAttributeType
as our amount attribute type isDecimal
. - We declare
NSFetchRequest
passing theExpenseLog
as the entity name. We assigncategory
as the propertiesToGroupBy as we want the result grouped by category. ForpropertiesToFech
, we pass the sum expression and category so the request can return those two values in a single result. At last, we set the result type as.dictionaryResultType
. In this case, the results will be an array ofDictionary
with values of sum and category instead of managed object model. - At last, we tell the context to execute the request and store the result in property. Then, we applied
map
for each of result so we can transform it to a tuple of category and value of total sum by parsing the dictionary using thesum
andcategory
key. Finally, we pass the array to the closure completion handler.
To integrate this, navigate to DashboardTabView
and implement the fetchTotalSums
method with the following code.
struct DashboardTabView: View {
@Environment(\.managedObjectContext)
var context: NSManagedObjectContext
// ....
func fetchTotalSums() {
ExpenseLog.fetchAllCategoriesTotalAmountSum(context: self.context) { (results) in
guard !results.isEmpty else { return }
let totalSum = results.map { $0.sum }.reduce(0, +)
self.totalExpenses = totalSum
self.categoriesSum = results.map({ (result) -> CategorySum in
return CategorySum(sum: result.sum, category: result.category)
})
}
}
}
Here, we just use the static method from ExpenseLog
to fetch the total sum for each category. Then, in the completion handler, we use reduce
to get the total expenses for all categories and assign it to the totalExpenses
state. At last, we map the array of a tuple into an array of CategorySum
struct as this type implements Identifiable
and will be used to drive the list of categories expense and the Pie Chart.
Try to build and run the project to see the final result of the project!
Conclusion
That's it! Congratulations on building your own expense tracker app using Core Data
for persistence. With Core Data
, we can create model entities, fetch data using complex predicate query filters, and sort descriptors. We also don't have to worry about view synchronization as we can use @FetchRequest
property wrapper to bind between UI and data source change. In the next article, we will explore how we can sync our Core Data
to the cloud using CloudKit
integration.
Until the next time, let's keep the lifelong learning goes on!
Challenge
I have several challenges for you all to improve this App to be much better in terms of features with your new knowledge. There are several essential features that the App are still missing, such as:
- Monthly dashboard based View. Currently, we have shown all the total sum of expenses without constraining the date. You can improve this by showing current month data at a glance.
- Add an additional date range filter mechanism to filter the data based on the date when querying.
- Add Income log and budget features to the App.